<template>
  <IPopover
    flip
    :class="widthClass"
    :busy="isPopoverDisabled"
    :title="$t('core::app.associate_with_record')"
    @hide="cancelSearch"
    @show="handlePopoverShow"
  >
    <button
      type="button"
      class="link text-sm focus:outline-none"
      v-text="associationsText"
    />

    <template #popper>
      <div class="p-4">
        <IFormGroup class="relative">
          <IFormInput
            v-model="searchQuery"
            class="pr-8"
            :placeholder="$t('core::app.search_records')"
            @input="search"
          />

          <a
            v-show="searchQuery"
            href="#"
            class="absolute right-3 top-2.5 focus:outline-none"
            @click.prevent="cancelSearch"
          >
            <Icon icon="X" class="h-5 w-5 text-neutral-400" />
          </a>
        </IFormGroup>

        <IOverlay :show="isLoading">
          <p
            v-show="
              isSearching &&
              !hasSearchResults &&
              !isLoading &&
              !minimumAsyncCharactersRequirement
            "
            v-t="'core::app.no_search_results'"
            class="text-center text-sm text-neutral-600 dark:text-neutral-300"
          />

          <p
            v-show="isSearching && minimumAsyncCharactersRequirement"
            class="text-center text-sm text-neutral-600 dark:text-neutral-300"
            v-text="
              $t('core::app.type_more_to_search', {
                characters: totalCharactersLeftToPerformSearch,
              })
            "
          />

          <div v-for="data in records" :key="data.resource">
            <p
              v-show="data.records.length > 0"
              class="mb-2 mt-3 text-sm font-medium text-neutral-700 dark:text-neutral-100"
              v-text="data.title"
            />

            <div
              v-for="(record, index) in data.records"
              :key="data.resource + '-' + record.id"
              class="flex items-center"
            >
              <IFormCheckbox
                :id="data.resource + '-' + record.id"
                v-model:checked="selected[data.resource]"
                class="grow"
                :value="record.id"
                :disabled="
                  record.disabled ||
                  (primaryRecordDisabled === true &&
                    primaryResourceName === data.resource &&
                    hasPrimaryRecord &&
                    parseInt(primaryRecord.id) === parseInt(record.id))
                "
                @change="
                  onCheckboxChange(record, data.resource, data.is_search)
                "
              >
                {{ record.display_name }}
              </IFormCheckbox>

              <RouterLink
                v-if="record.path && record.path !== $route.path"
                :to="record.path"
                class="mt-0.5 self-start text-neutral-500 dark:text-neutral-400"
              >
                <Icon icon="Eye" class="ml-2 h-4 w-4" />
              </RouterLink>

              <slot
                name="after-record"
                :index="index"
                :title="data.title"
                :resource="data.resource"
                :record="record"
                :is-searching="isSearching"
                :is-selected="selected[data.resource].includes(record.id)"
                :selected-records="selected[data.resource]"
              />
            </div>
          </div>
        </IOverlay>
      </div>
    </template>
  </IPopover>
</template>

<script setup>
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import castArray from 'lodash/castArray'
import debounce from 'lodash/debounce'
import find from 'lodash/find'
import findIndex from 'lodash/findIndex'
import map from 'lodash/map'
import orderBy from 'lodash/orderBy'
import sortBy from 'lodash/sortBy'
import uniq from 'lodash/uniq'

import { useLoader } from '@/Core/composables/useLoader'
import { useResourceable } from '@/Core/composables/useResourceable'
import { CancelToken } from '@/Core/services/HTTP'

const props = defineProps({
  widthClass: { type: String, default: 'w-72' },
  // The actual v-model for the selected associations
  modelValue: Object,
  resourceName: String,
  resourceId: [String, Number],
  associationsCount: Number,

  primaryResourceName: String,
  primaryRecord: Object,
  primaryRecordDisabled: Boolean,

  initialAssociateables: Object,
  associateables: Object, // selected by default

  limitInitialAssociateables: { type: Number, default: 3 },

  // Indicates whether the popover is disabled
  disabled: Boolean,

  excludedResources: [String, Array],
})

const emit = defineEmits([
  'update:modelValue',
  'update:associationsCount',
  'synced',
  'change',
])

const { t } = useI18n()
const { setLoading, isLoading } = useLoader()

const minimumAsyncCharacters = 2
const totalAsyncSearchCharacters = ref(0)
const minimumAsyncCharactersRequirement = ref(false)
const limitSearchResults = 5
const searchQuery = ref('')
const associatedResources = ref([])
const isDisabledLocal = ref(false)
// The selected associations
const selected = ref({})
// Associations selected from search results
const selectedFromSearchResults = ref({})
const searchResults = ref({})
const cancelTokens = {}

const availableAssociateables = {
  contacts: {
    title: t('contacts::contact.contacts'),
    resource: 'contacts',
  },
  companies: {
    title: t('contacts::company.companies'),
    resource: 'companies',
  },
  deals: {
    title: t('deals::deal.deals'),
    resource: 'deals',
  },
}

const { syncAssociations, associationsBeingSaved } = useResourceable(
  toRef(props, 'resourceName')
)

if (props.excludedResources) {
  castArray(props.excludedResources).forEach(resourceName => {
    delete availableAssociateables[resourceName]
  })
}

const isPopoverDisabled = computed(
  () => props.disabled || isDisabledLocal.value || associationsBeingSaved.value
)

const totalCharactersLeftToPerformSearch = computed(
  () => minimumAsyncCharacters - totalAsyncSearchCharacters.value
)

const isSearching = computed(() => searchQuery.value != '')

const hasPrimaryRecord = computed(() => Boolean(props.primaryRecord))

/**
 * The available associations resources, sorted as the primary is always first
 */
const resources = computed(() =>
  sortBy(Object.keys(availableAssociateables), resource => {
    return [resource !== props.primaryResourceName, resource]
  })
)

const allSelectedIdsString = computed(() => {
  let ids = []

  for (let resource in resources.value) {
    ids = ids.concat(getSelectedResourceIds(resource))
  }

  return ids.join(',')
})

const hasSearchResults = computed(() =>
  resources.value.some(
    resource =>
      searchResults.value[resource] &&
      searchResults.value[resource].records.length > 0
  )
)

const records = computed(() => {
  if (hasSearchResults.value) {
    return searchResults.value
  }

  let data = {}

  let addRecord = (resourceName, record) => {
    if (
      findIndex(data[resourceName].records, ['id', parseInt(record.id)]) === -1
    ) {
      data[resourceName].records.push(record)
    }
  }

  resources.value.forEach(resource => {
    data[resource] = Object.assign({}, availableAssociateables[resource], {
      records: [],
    })

    // Push the primary associateable
    if (hasPrimaryRecord.value && resource === props.primaryResourceName) {
      addRecord(resource, props.primaryRecord)
    }

    if (props.initialAssociateables) {
      getParsedAssociateablesFromInitialData(resource).forEach(record =>
        addRecord(resource, record)
      )
    }

    if (Object.hasOwn(props.associateables || {}, resource)) {
      props.associateables[resource].forEach(record =>
        addRecord(resource, record)
      )
    }

    if (Object.hasOwn(associatedResources.value, resource)) {
      associatedResources.value[resource].forEach(record =>
        addRecord(resource, record)
      )
    }

    // Check for any selected from search
    if (Object.hasOwn(selectedFromSearchResults.value, resource)) {
      selectedFromSearchResults.value[resource].records.forEach(record =>
        addRecord(resource, record)
      )
    }
  })

  return data
})

const totalSelected = computed(() => {
  let total = props.associationsCount || 0

  if (
    resources.value.some(
      resource =>
        selected.value[resource] && selected.value[resource].length > 0
    )
  ) {
    total = calculateTotalSelectedFromSelectedObject()
  }

  return total
})

const associationsText = computed(() => {
  if (totalSelected.value === 0) {
    return t('core::app.no_associations')
  }

  return t('core::app.associated_with_total_records', {
    count: totalSelected.value,
  })
})

function calculateTotalSelectedFromSelectedObject() {
  let total = 0

  for (let resource in selected.value) {
    total += selected.value[resource].length
  }

  return total
}

function retrieveAssociatedResources() {
  isDisabledLocal.value = true

  Innoclapps.request(
    `/associations/${props.resourceName}/${props.resourceId}`,
    {
      perPage: 100,
    }
  )
    .then(({ data }) => {
      associatedResources.value = data
      setSelectedRecords()
    })
    .finally(() => (isDisabledLocal.value = false))
}

function handlePopoverShow() {
  if (props.resourceName && props.resourceId) {
    retrieveAssociatedResources()
  }
}

function getParsedAssociateablesFromInitialData(resource) {
  return orderBy(
    (props.initialAssociateables[resource] || []).slice(
      0,
      props.limitInitialAssociateables
    ),
    'created_at',
    'desc'
  )
}

function createResolveableRequests(q) {
  // The order of the promises must be the same
  // like in the order of the availableAssociateables keys data variable
  let promises = []

  resources.value.forEach(resource => {
    promises.push(
      Innoclapps.request(`/${resource}/search`, {
        params: {
          q: q,
          take: limitSearchResults,
        },
        cancelToken: new CancelToken(token => (cancelTokens[resource] = token)),
      })
    )
  })

  return promises
}

function cancelPreviousRequests() {
  Object.keys(cancelTokens).forEach(resource => {
    if (cancelTokens[resource]) {
      cancelTokens[resource]()
    }
  })
}

function onCheckboxChange(record, resource, fromSearch) {
  if (!selectedFromSearchResults.value[resource] && fromSearch) {
    selectedFromSearchResults.value[resource] = {
      records: [],
      is_search: fromSearch,
    }
  }

  nextTick(() => {
    // User checked record selected from search
    if (selected.value[resource].includes(record.id) && fromSearch) {
      selectedFromSearchResults.value[resource].records.push(record)
    } else if (selectedFromSearchResults.value[resource]) {
      // Unchecked, now remove it it from the selectedFromSearchResults
      let selectedIndex = findIndex(
        selectedFromSearchResults.value[resource].records,
        ['id', parseInt(record.id)]
      )

      if (selectedIndex != -1) {
        selectedFromSearchResults.value[resource].records.splice(
          selectedIndex,
          1
        )
      }
    }

    if (props.modelValue == undefined) {
      syncAssociations(props.resourceId, selected.value).then(updatedRecord => {
        emit('synced', updatedRecord)
        emit('change', selected.value)
        // Re-fetch the all of the associations to reflect the "is_primary_associated" attribute.
        retrieveAssociatedResources()
      })
    } else {
      emit('change', selected.value)
    }
  })

  nextTick(() =>
    emit('update:associationsCount', calculateTotalSelectedFromSelectedObject())
  )
}

function cancelSearch() {
  searchQuery.value = ''
  search('')
  cancelPreviousRequests()
}

const search = debounce(function (q) {
  const totalCharacters = q.length

  if (totalCharacters === 0) {
    searchResults.value = {}

    return
  }

  totalAsyncSearchCharacters.value = totalCharacters

  if (totalCharacters < minimumAsyncCharacters) {
    minimumAsyncCharactersRequirement.value = true

    return q
  }

  minimumAsyncCharactersRequirement.value = false
  cancelPreviousRequests()
  setLoading(true)

  Promise.all(createResolveableRequests(q)).then(values => {
    let results = {}

    resources.value.forEach((resource, key) => {
      results[resource] = Object.assign({}, availableAssociateables[resource], {
        records: map(values[key].data, record => {
          record.from_search = true

          // If the record it's originally disabled and exists in search results,
          // make sure it's disabled via the search results records as well.
          record.disabled =
            find(records.value[resource]?.records || [], ['id', record.id])
              ?.disabled || false

          return record
        }),
        is_search: true,
      })
    })

    searchResults.value = results

    setLoading(false)
  })
}, 650)

function getSelectedResourceIds(resource) {
  let ids =
    props.modelValue && props.modelValue[resource]
      ? props.modelValue[resource]
      : []

  if (Object.hasOwn(associatedResources.value, resource)) {
    ids = ids.concat(map(associatedResources.value[resource], 'id'))
  }

  if (Object.hasOwn(props.associateables || {}, resource)) {
    ids = ids.concat(map(props.associateables[resource], 'id'))
  }

  return uniq(ids)
}

function setSelectedRecords() {
  const _selected = {}

  for (let resource of resources.value) {
    _selected[resource] = getSelectedResourceIds(resource)

    // When provided and not disabled, the primary resource is always selected.
    if (
      resource === props.primaryResourceName &&
      props.primaryRecordDisabled === true &&
      hasPrimaryRecord.value &&
      !_selected[resource].includes(props.primaryRecord.id)
    ) {
      _selected[resource].push(props.primaryRecord.id)
    }
  }

  selected.value = _selected
  emit('update:modelValue', _selected)
}

watch(allSelectedIdsString, () => {
  setSelectedRecords()
})

onMounted(setSelectedRecords)

defineExpose({ retrieveAssociatedResources })
</script>
